隨著專案開始需要透過環境變數管理資料庫設定,我們通常會結合 @nestjs/config 與 TypeORM 的 forRootAsync(),並注入 ConfigService 取得資料庫連線設定。
看似標準的設定,結果啟動時卻拋出這樣的錯誤:
Nest can't resolve dependencies of the TypeOrmModuleOptions (?).
明明 ConfigModule 和 TypeOrmModule 都寫在 AppModule 的 imports 裡,為什麼還是拿不到 ConfigService?
有人第一直覺或許是:
「是不是 ConfigModule 排得太後面,TypeORM 啟動時它還沒準備好?」
但問題真的出在順序嗎?這篇就從這個直覺開始,一路找到 ConfigService 真正注入失敗的地方。
我們用最精簡的模組結構來重現這個問題:
@Module({
imports: [
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
type: 'sqlite' as const,
database: configService.get<string>('DB_NAME'),
synchronize: true,
}),
}),
ConfigModule.forRoot(),
],
})
export class AppModule {}
啟動後會拋出錯誤訊息:
[Error]: Nest can't resolve dependencies of the
TypeOrmModuleOptions (?). Please make sure that the argument ConfigService at
index [0] is available in the TypeOrmCoreModule module.
遇到這個錯誤時,先別急著把焦點放在「找不到 ConfigService」上。錯誤訊息最後其實給了最核心的提示:
ConfigService 在 TypeOrmCoreModule 的模組作用域(module context)中不可用。
你可能會納悶:「我們明明沒寫過 TypeOrmCoreModule 啊?」事實上,它是 TypeOrmModule.forRootAsync() 在背後自動產生的內部模組。
這也提醒我們:當依賴注入失敗時,比起單純尋找「誰失聯了」,更重要的往往是鎖定「它是在哪個模組作用域裡失聯的」。
ConfigModule 放太後面了?看到這個錯誤,第一個很容易冒出來的念頭就是:
「是不是 ConfigModule 排在後面,所以 TypeORM 開始初始化時,ConfigService 還沒準備好。」
我們就直接做個實驗,把 ConfigModule.forRoot() 搬到最前面:
@Module({
imports: [
ConfigModule.forRoot(), // 移到陣列第一位
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({ ... }),
}),
],
})
再次重啟,結果出現一樣的錯誤訊息。
Nest 並不是照著 imports 陣列由上到下邊讀取邊建立模組。
在啟動時,Nest 會先從根模組開始掃描整個應用程式的模組結構,收集各個模組、動態模組,以及它們宣告的 imports、providers、exports 等資訊,建立模組之間的關係。
完成這個階段後,Nest 才會進入 provider 的建立過程,並在實例化 provider 時解析它所需要注入的依賴。

這也是為什麼在這個案例中,單純交換 imports 陣列中的位置:
// 寫法 A
imports: [TypeOrmModule.forRootAsync({ ... }), ConfigModule.forRoot()]
// 寫法 B
imports: [ConfigModule.forRoot(), TypeOrmModule.forRootAsync({ ... })]
並不會改變 TypeOrmCoreModule 能不能存取 ConfigService。
因為這兩種寫法都沒有改變兩個模組之間的依賴關係:ConfigModule 仍然只是被 AppModule 匯入,並沒有因此進入 TypeOrmCoreModule 可以取得依賴的模組作用域。
既然不是順序問題,那真正的邊界在哪?
Nest 在解析 provider 的依賴時,會以它所在的模組作用域(Module Context)為基礎,尋找目前可見的 provider。主要包含:
providers
imports 引入,而且對方有 exports 的 providerisGlobal: true 的 ConfigModule
換句話說,模組的可見性不會因為「共同被某個外層模組匯入」,就自動在彼此之間橫向共享。
回到我們的案例,ConfigModule 和 TypeOrmModule 雖然都被 AppModule 匯入,但這只代表它們都和 AppModule 建立了模組關係,並不代表 TypeOrmCoreModule 也能自動存取 ConfigModule 匯出的 ConfigService。
TypeOrmModule.forRootAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({ ... }),
})
inject: [ConfigService] 的意思只是:「等等執行 factory 時,我需要一個 ConfigService。」它不是召喚術,不會因為名字寫在這裡,Nest 就自動幫你找到 ConfigModule 並建立依賴關係。
所以真正的問題,不是 ConfigModule 還沒初始化完成,而是執行 TypeORM 設定 factory 函式的 TypeOrmCoreModule,它自己的模組作用域裡沒有可用的 ConfigService。
這也解釋了為什麼錯誤訊息會特別點名 TypeOrmCoreModule——那才是真正解析 ConfigService 失敗的地方。
ConfigModule既然問題是 TypeOrmCoreModule 的作用域中沒有可用的 ConfigService,那我們就把它明確補上:
TypeOrmModule.forRootAsync({
imports: [ConfigModule], // 加上這行,把提供 ConfigService 的模組帶入
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
type: 'sqlite' as const,
database: configService.get<string>('DB_NAME'),
synchronize: true,
}),
}),
這是最直接、也最符合 Nest 模組封裝設計的做法:需要在哪個模組中使用 ConfigService,就明確把提供它的 ConfigModule 引入。其實 Nest 在報錯時,就已經將這個解法寫在提示裡告訴我們了:
- If ConfigService is exported from a separate @Module, is that module imported within TypeOrmCoreModule?
ConfigModule 變成全域可見既然 ConfigModule 到處都會用到,也可以從一開始就設定為全域模組(Global Module):
ConfigModule.forRoot({
isGlobal: true, // 宣告此模組為全域
}),
設定 isGlobal: true 後,ConfigModule 就會被註冊為全域模組。之後其他模組要使用它所匯出的 provider 時,就不必逐一匯入ConfigModule。
顯式引入 ConfigModule |
設定 isGlobal: true |
|
|---|---|---|
| 優點 | 依賴關係清楚,模組之間的邊界比較明確,也較方便進行隔離測試 | 不必在各個模組重複宣告 imports,使用上較方便 |
| 缺點 | 每個需要使用的地方都必須明確引入 | 依賴關係較不容易直接從模組的 imports 看出來 |
| 適合情境 | 一般功能模組,或希望維持明確模組邊界的情況 | ConfigService 這類幾乎整個專案都會使用的基礎服務 |
forRootAsync() 的 factory 要注入什麼 provider,那個 provider 就必須在 factory 所屬的模組作用域中可見。外層 AppModule 有匯入,不代表裡面的動態模組就一定看得到。ConfigService 能不能被注入的,仍然是模組之間的依賴關係與作用域。